You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
353 lines
10 KiB
353 lines
10 KiB
<script setup lang="ts">
|
|
import { useAuthSession } from '../../../composables/useAuthSession'
|
|
import { generateRandomPostSlug } from '../../../utils/post-slug'
|
|
import { clearPostPreviewDrafts, createPostPreviewDraft } from '../../../utils/post-preview-draft'
|
|
|
|
const route = useRoute()
|
|
const id = computed(() => route.params.id as string)
|
|
const { user } = useAuthSession()
|
|
const { fetchData } = useClientApi()
|
|
const toast = useToast()
|
|
|
|
const state = reactive({
|
|
title: '',
|
|
slug: '',
|
|
excerpt: '',
|
|
bodyMarkdown: '',
|
|
tags: [] as string[],
|
|
visibility: 'private',
|
|
shareToken: '' as string | null,
|
|
})
|
|
const availableTags = ref<string[]>([])
|
|
const loading = ref(true)
|
|
const saving = ref(false)
|
|
const persistedSlug = ref('')
|
|
const visibilityItems = [
|
|
{ label: '私密', value: 'private' },
|
|
{ label: '公开', value: 'public' },
|
|
{ label: '仅链接', value: 'unlisted' },
|
|
]
|
|
const bodyLength = computed(() => state.bodyMarkdown.trim().length)
|
|
|
|
const publicPostHref = computed(() => {
|
|
const hasUnsavedSlugChange = state.slug !== persistedSlug.value
|
|
if (state.visibility !== 'public' || hasUnsavedSlugChange) {
|
|
return `/me/posts/preview/${id.value}`
|
|
}
|
|
const ps = user.value?.publicSlug
|
|
if (!ps || !state.slug) {
|
|
return ''
|
|
}
|
|
return `/@${ps}/posts/${encodeURIComponent(state.slug)}`
|
|
})
|
|
|
|
async function load(options?: { silent?: boolean }) {
|
|
const silent = options?.silent === true
|
|
if (!silent) {
|
|
loading.value = true
|
|
}
|
|
try {
|
|
const { post: p, availableTags: allTags } = await fetchData<{ post: typeof state; availableTags?: string[] }>(
|
|
`/api/me/posts/${id.value}`,
|
|
)
|
|
Object.assign(state, {
|
|
title: p.title,
|
|
slug: p.slug,
|
|
excerpt: p.excerpt,
|
|
bodyMarkdown: p.bodyMarkdown,
|
|
tags: p.tags ?? [],
|
|
visibility: p.visibility,
|
|
shareToken: p.shareToken ?? null,
|
|
})
|
|
availableTags.value = allTags ?? []
|
|
persistedSlug.value = p.slug
|
|
} finally {
|
|
if (!silent) {
|
|
loading.value = false
|
|
}
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
void load()
|
|
})
|
|
|
|
watch(id, () => {
|
|
void load()
|
|
})
|
|
|
|
usePageTitle(() => {
|
|
const t = state.title.trim()
|
|
return t ? [t, '编辑'] : ['编辑文章']
|
|
})
|
|
|
|
function generateSlugFromTitle() {
|
|
const previous = state.slug
|
|
const normalized = generateRandomPostSlug()
|
|
|
|
state.slug = normalized
|
|
|
|
if (!state.slug) {
|
|
toast.add({ title: '未生成 slug,请检查标题内容', color: 'warning' })
|
|
return
|
|
}
|
|
|
|
if (state.slug === previous) {
|
|
toast.add({ title: 'slug 未变化', color: 'neutral' })
|
|
return
|
|
}
|
|
|
|
toast.add({ title: '已生成 slug', color: 'success' })
|
|
}
|
|
|
|
async function save() {
|
|
saving.value = true
|
|
try {
|
|
await fetchData(`/api/me/posts/${id.value}`, {
|
|
method: 'PUT',
|
|
body: {
|
|
title: state.title,
|
|
slug: state.slug,
|
|
excerpt: state.excerpt,
|
|
bodyMarkdown: state.bodyMarkdown,
|
|
tags: state.tags,
|
|
visibility: state.visibility,
|
|
},
|
|
})
|
|
if (import.meta.client) {
|
|
clearPostPreviewDrafts()
|
|
}
|
|
await load({ silent: true })
|
|
toast.add({ title: '文章已保存', color: 'success' })
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
async function remove() {
|
|
await fetchData(`/api/me/posts/${id.value}`, { method: 'DELETE' })
|
|
if (import.meta.client) {
|
|
clearPostPreviewDrafts()
|
|
}
|
|
toast.add({ title: '文章已删除', color: 'success' })
|
|
await navigateTo('/me/posts')
|
|
}
|
|
|
|
const shareUrl = computed(() => {
|
|
const slug = user.value?.publicSlug?.trim()
|
|
if (state.visibility !== 'unlisted' || !state.shareToken || !slug) {
|
|
return ''
|
|
}
|
|
if (import.meta.client) {
|
|
return `${window.location.origin}/p/${encodeURIComponent(slug)}/t/${encodeURIComponent(state.shareToken)}`
|
|
}
|
|
return ''
|
|
})
|
|
|
|
async function copyShareUrl() {
|
|
if (!shareUrl.value || !import.meta.client) {
|
|
return
|
|
}
|
|
try {
|
|
await navigator.clipboard.writeText(shareUrl.value)
|
|
toast.add({ title: '分享链接已复制', color: 'success' })
|
|
} catch {
|
|
toast.add({ title: '复制失败,请手动复制', color: 'warning' })
|
|
}
|
|
}
|
|
|
|
function openPreviewInNewPage() {
|
|
if (!import.meta.client) {
|
|
return
|
|
}
|
|
const key = createPostPreviewDraft({
|
|
title: state.title,
|
|
excerpt: state.excerpt,
|
|
bodyMarkdown: state.bodyMarkdown,
|
|
visibility: state.visibility as 'private' | 'unlisted' | 'public',
|
|
})
|
|
const url = `/me/posts/preview/draft?key=${encodeURIComponent(key)}`
|
|
window.open(url, '_blank', 'noopener')
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<UContainer class="py-8 max-w-[1600px] space-y-6">
|
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
<h1 class="text-2xl font-semibold tracking-tight">
|
|
编辑文章
|
|
</h1>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<UButton
|
|
v-if="publicPostHref"
|
|
:to="publicPostHref"
|
|
variant="soft"
|
|
color="neutral"
|
|
size="sm"
|
|
>
|
|
详情
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="loading" class="text-muted">
|
|
加载中…
|
|
</div>
|
|
|
|
<template v-else>
|
|
<UForm
|
|
id="edit-post-form"
|
|
:state="state"
|
|
class="grid gap-6 min-w-0 xl:grid-cols-[minmax(0,1fr)_280px]"
|
|
@submit.prevent="save"
|
|
>
|
|
<div class="space-y-6 min-w-0">
|
|
<UCard :ui="{ body: 'p-4 sm:p-6 space-y-4' }">
|
|
<UFormField label="标题" name="title" required class="w-full">
|
|
<UInput
|
|
v-model="state.title"
|
|
class="w-full"
|
|
placeholder="例如:我的 2026 开发工作流复盘"
|
|
/>
|
|
</UFormField>
|
|
|
|
<UFormField label="摘要" name="excerpt" required class="w-full">
|
|
<UTextarea
|
|
v-model="state.excerpt"
|
|
class="w-full"
|
|
:rows="3"
|
|
autoresize
|
|
placeholder="一句话概括文章核心内容,便于列表页快速浏览。"
|
|
/>
|
|
</UFormField>
|
|
</UCard>
|
|
|
|
<UCard :ui="{ body: 'p-4 sm:p-6 space-y-3' }">
|
|
<div class="flex items-center justify-between gap-3">
|
|
<h2 class="text-base font-medium">
|
|
正文内容
|
|
</h2>
|
|
<span class="text-xs text-muted">字数 {{ bodyLength }}</span>
|
|
</div>
|
|
<PostBodyMarkdownEditor v-model="state.bodyMarkdown" />
|
|
<UFormField label="正文" name="bodyMarkdown" required class="sr-only" />
|
|
</UCard>
|
|
</div>
|
|
|
|
<div class="side-rail-scroll space-y-6 xl:sticky xl:top-20 xl:self-start xl:max-h-[calc(100vh-5rem)] xl:overflow-y-auto xl:pr-1">
|
|
<UCard :ui="{ body: 'p-4 sm:p-5 space-y-4' }">
|
|
<h2 class="text-base font-medium">
|
|
发布设置
|
|
</h2>
|
|
<UFormField label="slug" name="slug" required>
|
|
<div class="flex items-center gap-2">
|
|
<UInput v-model="state.slug" placeholder="my-post-slug" class="flex-1" />
|
|
<UButton
|
|
type="button"
|
|
variant="soft"
|
|
color="neutral"
|
|
icon="i-lucide-wand-sparkles"
|
|
label="生成"
|
|
@click="generateSlugFromTitle"
|
|
/>
|
|
</div>
|
|
</UFormField>
|
|
<UFormField label="可见性" name="visibility">
|
|
<USelect v-model="state.visibility" :items="visibilityItems" />
|
|
</UFormField>
|
|
<div
|
|
v-if="shareUrl"
|
|
class="rounded-md border border-default bg-elevated/40 p-3 space-y-2"
|
|
>
|
|
<p class="text-sm font-medium">
|
|
仅链接分享
|
|
</p>
|
|
<div class="flex flex-col gap-2">
|
|
<UInput
|
|
readonly
|
|
size="sm"
|
|
class="font-mono text-xs w-full min-w-0"
|
|
:model-value="shareUrl"
|
|
/>
|
|
<div class="flex flex-wrap gap-2">
|
|
<UButton size="sm" @click="copyShareUrl">
|
|
复制链接
|
|
</UButton>
|
|
<UButton
|
|
size="sm"
|
|
color="neutral"
|
|
variant="outline"
|
|
:to="shareUrl"
|
|
target="_blank"
|
|
>
|
|
打开
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</UCard>
|
|
|
|
<UCard :ui="{ body: 'p-4 sm:p-5 space-y-3' }">
|
|
<h2 class="text-base font-medium">
|
|
标签
|
|
</h2>
|
|
<p class="text-xs text-muted">
|
|
用于列表筛选与归类。
|
|
</p>
|
|
<UFormField label="文章标签" name="tags" class="w-full">
|
|
<PostTagsInput
|
|
v-model="state.tags"
|
|
:suggestions="availableTags"
|
|
placeholder="输入后回车,例如:复盘、Nuxt"
|
|
/>
|
|
</UFormField>
|
|
</UCard>
|
|
|
|
<UCard :ui="{ body: 'p-4 sm:p-5 space-y-3' }">
|
|
<h2 class="text-base font-medium">
|
|
操作
|
|
</h2>
|
|
<UButton type="button" color="neutral" variant="soft" block @click="openPreviewInNewPage">
|
|
预览(新窗口)
|
|
</UButton>
|
|
<UButton type="submit" :loading="saving" block>
|
|
保存文章
|
|
</UButton>
|
|
<UButton color="error" variant="soft" type="button" block @click="remove">
|
|
删除文章
|
|
</UButton>
|
|
</UCard>
|
|
</div>
|
|
</UForm>
|
|
</template>
|
|
</UContainer>
|
|
</template>
|
|
|
|
<style scoped>
|
|
@media (min-width: 1280px) {
|
|
.side-rail-scroll {
|
|
scrollbar-width: thin;
|
|
scrollbar-color: rgba(148, 163, 184, 0.55) transparent;
|
|
}
|
|
|
|
.side-rail-scroll::-webkit-scrollbar {
|
|
width: 10px;
|
|
}
|
|
|
|
.side-rail-scroll::-webkit-scrollbar-track {
|
|
background: transparent;
|
|
}
|
|
|
|
.side-rail-scroll::-webkit-scrollbar-thumb {
|
|
border-radius: 9999px;
|
|
background: rgba(148, 163, 184, 0.45);
|
|
border: 2px solid transparent;
|
|
background-clip: content-box;
|
|
}
|
|
|
|
.side-rail-scroll::-webkit-scrollbar-thumb:hover {
|
|
background: rgba(148, 163, 184, 0.7);
|
|
background-clip: content-box;
|
|
}
|
|
}
|
|
</style>
|
|
|